Skip to content

sapi/cli: guard Content-Length overflow and enforce post_max_size - #22017

Merged
iliaal merged 1 commit into
php:masterfrom
iliaal:fix/gh-22003-cli-server-content-length
Aug 5, 2026
Merged

sapi/cli: guard Content-Length overflow and enforce post_max_size#22017
iliaal merged 1 commit into
php:masterfrom
iliaal:fix/gh-22003-cli-server-content-length

Conversation

@iliaal

@iliaal iliaal commented May 12, 2026

Copy link
Copy Markdown
Contributor

The dev server crashes when Content-Length wraps ssize_t (30+ digit value), or when a legitimately large Content-Length passes pemalloc and aborts the process.

Guard the parser's Content-Length and chunked-size accumulators against SSIZE_MAX, then reject oversize Content-Length in on_headers_complete and reply 413 with the configured post_max_size in the body.

Fixes #22003

Comment thread sapi/cli/php_http_parser.c Outdated
# ifdef _WIN64
# define SSIZE_MAX _I64_MAX
# else
# define SSIZE_MAX INT_MAX

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more or less of the same (i.e. sizeof 4) but LONG_MAX is more accurate I think. Or even PTRDIFF_MAX.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to PTRDIFF_MAX, drops the _WIN64 split.

Comment thread sapi/cli/php_cli_server.c

php_http_parser_init(&client->parser, PHP_HTTP_REQUEST);
client->request_read = false;
client->too_large_post = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too_large_post is only cleared in client_ctor. Works now since each request rebuilds the client, but if keep-alive ever reuses the struct, the flag
lingers and every follow-up request gets a bogus 413. Worth resetting it with the other per-request state, or leaving a note at the declaration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request_read, last_header_element, and current_header_* are all initialized only in client_ctor for the same reason: the client is torn down at request end. Keep-alive would need a shared per-request reset covering the lot, not just too_large_post.

@iliaal
iliaal force-pushed the fix/gh-22003-cli-server-content-length branch from 612aa33 to 3827cd3 Compare May 12, 2026 12:15
@iliaal
iliaal requested a review from devnexen May 12, 2026 13:30
@LamentXU123

LamentXU123 commented May 12, 2026

Copy link
Copy Markdown
Member

The CI failure is unrelated... But the reason seems to be not hard to find as it *might* be a race condition where the address (port) is used by another test.

@iliaal You could force push this again to rerun CI :)

@iliaal

iliaal commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

The CI failure is unrelated... But the reason seems to be not hard to find as it might be a race condition where the address (port) is used by another test.

@iliaal You could force push this again to rerun CI :)

I am 91% 😆 sure it is unrelated. it is in a completely different code path.
socket_create_listen(): unable to bind to given address [48]: Address already in use
error

@iliaal
iliaal force-pushed the fix/gh-22003-cli-server-content-length branch from 3827cd3 to 89565d8 Compare May 12, 2026 16:04
@LamentXU123

Copy link
Copy Markdown
Member

The CI failure is unrelated... But the reason seems to be not hard to find as it might be a race condition where the address (port) is used by another test.
@iliaal You could force push this again to rerun CI :)

I am 91% 😆 sure it is unrelated. it is in a completely different code path. socket_create_listen(): unable to bind to given address [48]: Address already in use error

Yeah and I am having a headache to fix this. I hate github CI...

parser->content_length *= 10;
parser->content_length += ch - '0';
if (parser->content_length > (SSIZE_MAX - (ch - '0')) / 10) {
goto error;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point the header name has been copied in php_cli_server_client_read_request_on_header_field() and will leak. We likely need to free current_header_name / current_header_value in php_cli_server_client_dtor().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Freed both in php_cli_server_client_dtor() and dropped the two asserts, which sat behind content_sender_initialized and so never fired on that teardown. Valgrind on the Content-Length overflow case: 40 bytes definitely lost before, clean after.

parser->content_length += c;
if (parser->content_length > (SSIZE_MAX - c) / 16) {
goto error;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that it's still possible to exceed post_max_size with Transfer-Encoding: chunked, as php_cli_server_client_read_request_on_body() will accumulate chunks without checking the limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. The headers-complete check reads parser->content_length, which a chunked request never sets to the body size, so the limit now applies as the chunks accumulate in on_body(). That needed a parser change as well: all three on_body call sites discarded the callback's return value. Separately, the body buffer was reserved from the declared chunk size, so 7FFFFFFFFFFFFF followed by five bytes exited the server with "Out of memory" before any limit applied; that reservation is clamped now. Covered in gh22003.phpt.

@iliaal
iliaal force-pushed the fix/gh-22003-cli-server-content-length branch from 89565d8 to 1921a88 Compare July 31, 2026 17:07
@iliaal
iliaal requested a review from arnaud-lb July 31, 2026 17:39
Comment thread sapi/cli/php_cli_server.c
return 0;
}

static int php_cli_server_client_read_request_on_body(php_http_parser *parser, const char *at, size_t length)
{
php_cli_server_client *client = parser->data;

if (SG(post_max_size) > 0 && client->request.content_len + length > (size_t) SG(post_max_size)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can client->request.content_len + length overflow?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. length is capped by the 16 KB read buffer in php_cli_server_client_read_request(), and the check keeps content_len at or below post_max_size, so the sum stays below SIZE_MAX.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add an assertion and comment about this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

Comment thread sapi/cli/php_http_parser.c Outdated
@@ -1386,7 +1391,9 @@ size_t php_http_parser_execute (php_http_parser *parser,

to_read = MIN((size_t)(pe - p), (size_t)parser->content_length);
if (to_read > 0) {
if (settings->on_body) settings->on_body(parser, p, to_read);
if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
return (p - data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not goto error here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, indeed. The bare return never wrote state back to the parser, so it kept whatever it had on entry. Switched all three on_body sites and the on_headers_complete default to goto error.

Comment thread sapi/cli/php_cli_server.c Outdated
Comment on lines 1808 to 1816
if (!client->request.content) {
client->request.content = pemalloc(parser->content_length, 1);
size_t reserve = (size_t) parser->content_length;
if (SG(post_max_size) > 0 && reserve > (size_t) SG(post_max_size)) {
reserve = (size_t) SG(post_max_size);
}
client->request.content = pemalloc(reserve, 1);
client->request.content_len = 0;
}
client->request.content = perealloc(client->request.content, client->request.content_len + length, 1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the original code was not useful: The realloc is going to move the block to a smaller chunk, so the "reserve" malloc is not beneficial.

We could simplify this:

Suggested change
if (!client->request.content) {
client->request.content = pemalloc(parser->content_length, 1);
size_t reserve = (size_t) parser->content_length;
if (SG(post_max_size) > 0 && reserve > (size_t) SG(post_max_size)) {
reserve = (size_t) SG(post_max_size);
}
client->request.content = pemalloc(reserve, 1);
client->request.content_len = 0;
}
client->request.content = perealloc(client->request.content, client->request.content_len + length, 1);
client->request.content = safe_perealloc(client->request.content, 1, client->request.content_len, length, true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. That also drops the post_max_size clamp the reservation needed.

@iliaal
iliaal force-pushed the fix/gh-22003-cli-server-content-length branch from 1921a88 to ae252bf Compare August 4, 2026 15:29
@iliaal
iliaal requested a review from arnaud-lb August 4, 2026 15:47

@arnaud-lb arnaud-lb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one comment in https://github.com/php/php-src/pull/22017/changes#r3714310817, but this looks good to me otherwise!

The dev server's HTTP parser accumulates Content-Length digits into an
ssize_t without an overflow check; a 30-digit value wraps and the
consumer aborts on pemalloc. Guard the decimal and chunked-size
accumulators against SSIZE_MAX, then reject in on_headers_complete when
the parsed length exceeds post_max_size and reply 413 with the
configured limit in the body.

A chunked request carries no Content-Length, so enforce the same limit
as the chunks accumulate in on_body, and honour a non-zero return from
that callback in the parser. The body buffer was reserved from the
declared length and resized down to the bytes in hand on the next line,
so a chunk header of 7FFFFFFFFFFFFF aborted the server for no gain; grow
it from the received length instead. A parse error between a header name
and its value left the copied name owned by nobody; release both header
strings in php_cli_server_client_dtor().

Fixes phpGH-22003
@iliaal
iliaal force-pushed the fix/gh-22003-cli-server-content-length branch from ae252bf to e3ef9f4 Compare August 4, 2026 16:34
@iliaal
iliaal requested a review from arnaud-lb August 4, 2026 18:21

@arnaud-lb arnaud-lb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@iliaal
iliaal merged commit 454f1a6 into php:master Aug 5, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remote DoS via overflowed Content-Length

4 participants